Skip to content

live HR banner: read what it shows once the change has landed - #2437

Closed
UtkuDenizAltiok wants to merge 1 commit into
ryanbr:mainfrom
UtkuDenizAltiok:live-hr-banner-settled
Closed

UtkuDenizAltiok wants to merge 1 commit into
ryanbr:mainfrom
UtkuDenizAltiok:live-hr-banner-settled

Conversation

@UtkuDenizAltiok

@UtkuDenizAltiok UtkuDenizAltiok commented Sep 24, 2026 •

Copy link
Copy Markdown

What this PR does

A follow-up to #2422, found on the tester's phone the morning after it merged. He took the strap off at 10:48. The strap log reads "Strap: WRIST_OFF; live heart rate cleared" at 10:48:03, so the strap said it within about a second and NOOP cleared the heart rate, but no "Live HR banner: –" line follows: the banner kept his last number until iOS's stale date drew the dash, two to three minutes later. The same happened at 10:53:39, when the zeros of the strap going back on cleared it.

Why. The banner shows AppModel's smoothed rate (bpm), and it refreshed from a sink of its own on live.$heartRate. AppModel resets that median in a sink of its own on the same publisher. A @Published sink runs in willSet, and several sinks on one publisher run in no promised order, so the banner could read the median before AppModel had reset it, see a number where there was none, and push nothing. #2422 carried a comment saying AppModel's sinks run first; that was wrong, and this removes it.

The fix. The banner is refreshed once the changes have landed: LiveHRBannerInputs.settled merges the heart rate, the link and the median, and debounces them to the end of the main queue's current turn, where each reads as it now is. A clear moves several of them in one turn and makes one refresh instead of one per value; a worn strap still makes one refresh per heart-rate tick, as before. What the banner shows, and when it starts, ends and renews, is unchanged.

The rest of the tester's run matched #2422: a dropped link showed the dash at once and the number came back a second after the reconnect (11:09:57 → 11:17:16); with NOOP swiped away the banner stayed, iOS drew the dash about two minutes later, and opening NOOP picked it up with the number within a second; the switch ended it and started it again at once. The strap now names WRIST_OFF in the log, so the stale-date question from the #2422 review can be answered from the next log, once the dash comes from WRIST_OFF.

Type of change

  • Bug fix

How it was tested

  • LiveHRBannerInputsTests (2, StrandTests): a cleared heart rate is read only once the clear has landed, and reads nil; a clear and a dropped link in one turn make one refresh. Seen to fail with the debounce removed: the reader saw [91, 91] (the old value, read inside the clear) and 6 refreshes instead of 1. Restored byte-identical (sha256).
  • Full local run on the head (bc5da07d, on 141cbd93), every step passed: WhoopStore 611 · StrandAnalytics 2056 · StrandImport 327 · doc lint · i18n · parity ledger, ratchet, governance (124) · macOS StrandTests 2,144 (only the two locale-dependent TodayCarryOverTests) · iOS build.
  • On hardware (WHOOP 5.0, iPhone), the tester's build with this PR, 13:48–13:55: strap off → "Strap: WRIST_OFF; live heart rate cleared" and "Live HR banner: – (strap connected, no heart rate)" in the same second (13:48:08), and he saw the dash on the Lock Screen 5–7 s later (the earlier build: 2–3 minutes). Back on → the number again by itself (13:51:10, after the strap's re-wear zeros). Bluetooth turned off → "Live HR banner: – (strap not connected)" in the same second (13:54:14); back on → reconnected 13:55:00 and the number at 13:55:01. NOOP stayed in the background throughout.

Checklist

  • No UI change
  • No new command; nothing written to the strap
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

🤖 Generated with Claude Code

A tester took the strap off at 10:48 (24 Sep 2026, build with ryanbr#2422). The strap log reads "Strap: WRIST_OFF; live
heart rate cleared" at 10:48:03, so the strap said it at once and NOOP cleared the heart rate, but no "Live HR banner:
–" line follows: the banner kept the last number until iOS's stale date drew the dash, two to three minutes later. The
same at 10:53:39, when the zeros of the strap going back on cleared it.

The banner shows AppModel's smoothed rate (`bpm`), and it refreshed from a sink of its own on the heart rate. AppModel
resets that median in a sink of its own on the same publisher; a `@Published` sink runs in willSet, and several sinks
on one publisher run in no promised order. So the banner could read the median before AppModel had reset it, see a
number where there was none, and push nothing. The earlier comment claiming AppModel's sinks run first was wrong.

The banner is now refreshed once the changes have landed (`LiveHRBannerInputs.settled`): the heart rate, the link and
the median are merged and debounced to the end of the main queue's current turn, where each reads as it now is. A
clear moves several of them in one turn and makes one refresh instead of one per value. Nothing else changes: what the
banner shows, when it starts, ends and renews.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UtkuDenizAltiok added a commit to UtkuDenizAltiok/noop that referenced this pull request Sep 24, 2026
UtkuDenizAltiok added a commit to UtkuDenizAltiok/noop that referenced this pull request Sep 24, 2026
…fix; ryanbr#2437 description updated; hr-timeline run detection fixed
ryanbr added a commit that referenced this pull request Sep 26, 2026
The rationale landed with a mechanism that does not hold. AppModel subscribes its
own sinks in `init()` and `follow()` runs later, from `StrandiOSApp`, so the
delivery order is fixed, and `ingestHR(nil, [])` has already taken the median
through `resetSmoothing()` by the time the banner's sink runs. Sink ordering was
never the hazard.

What the old shape missed is `bpm`, which it never observed at all. The median
moves on the R-R alone: `clearBiometrics()` clears the heart rate while the R-R
is still there, `ingestHR` keeps the median from `rr.last`, and the one refresh
the banner gets still reads a live number; `rr.removeAll()` then resets the
median with nothing the banner watches left to move.

The test also pins the delivery queue, since `refreshBanner` reads UIKit.

Refs #2437
@ryanbr

ryanbr commented Sep 26, 2026

Copy link
Copy Markdown
Owner

Merged via #2481, rebased so your commit keeps its authorship: a35d102, with 0efc747 on top.

Thanks @UtkuDenizAltiok. Adding model.$bpm to the inputs is the part that mattered, and the deferred read is right for keeping the three values consistent when a clear moves several of them in one turn. Resolving the rate, the link and the median in one place in the surviving refreshBanner is a real improvement on the old shape, which mixed a willSet parameter with a live read. The hardware check on the 5.0 and the byte-identical restore after the debounce-removal run made this quick to verify, and the two numbers you reported are exactly what the code predicts: the subscribe-time current value plus the willSet read, and 3 at subscribe plus rr, heartRate and connected.

One correction went in on top. The rationale attributed the bug to Combine sink ordering, and that does not hold here: AppModel.init() subscribes at AppModel.swift:288 while follow() runs later, from StrandiOSApp.swift:114, so AppModel's sink is always first, and ingestHR(heartRate: nil, rr: []) has already reached resetSmoothing() by the time the banner's sink runs.

The real hole is the one your $bpm subscription plugs: the banner never observed the median at all. It is a ten-second window, so it moves on the R-R alone and as samples age out. LiveState.clearBiometrics() shows it deterministically, clearing heartRate while rr is still populated so ingestHR takes the rate from rr.last and the median survives the one refresh the banner gets, then rr.removeAll() drives resetSmoothing() with nothing the banner watches left to move. The banner then keeps the last number until iOS's stale date draws the dash, which is the symptom in your log.

Also added a main-queue assertion to the first test, since refreshBanner reads UIApplication.shared.applicationState and ActivityKit and nothing else pinned the scheduler.

@ryanbr ryanbr closed this Sep 26, 2026
@UtkuDenizAltiok
UtkuDenizAltiok deleted the live-hr-banner-settled branch September 28, 2026 07:21
UtkuDenizAltiok added a commit to UtkuDenizAltiok/noop that referenced this pull request Sep 28, 2026
…2437 ryanbr#2444), ryanbr#2446 fixed upstream; logs+backup analysed (BACKLOG A background re-score, B ryanbr#2371 answered); fork tidied; build 0ad5ba9 shipped
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants